跳到主要内容

Java 并发编程-核心理论

概述

这篇是整个并发编程学习中的基石,基本后面学到的所有的知识都是围绕着下面的几个概念展开的

有序性

为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种: 1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2、指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3、内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1 是在语句2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句1 一定会在语句2 前面执行吗?

不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如上面的代码中,语句1 和语句2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2 先执行而语句1 后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4

这段代码有4个语句,那么可能的一个执行顺序是:

graph LR 语句2 --> 语句1 语句1 --> 语句3 语句3 --> 语句4

那么可不可能是这个执行顺序呢:

graph LR 语句2 --> 语句1 语句1 --> 语句4 语句4 --> 语句3

不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令 Instruction 2 必须用到 Instruction 1 的结果,那么处理器会保证 Instruction 1 会在 Instruction 2 之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程A:
context = loadContext(); //语句1
init = true; //语句2

//线程B:
while(!init ){
sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1 和语句2 没有数据依赖性,因此可能会被重排序。

假如发生了重排序,在线程A 执行过程中先执行语句2,而此是线程B 会以为初始化工作已经完成,那么就会跳出 while 循环,去执行 doSomethingwithconfig(context) 方法,而此时 context 并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着 “同生共死” 的感觉。

如下例子:

i = 9;

假若一个线程执行到这个语句时,假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取 i 的值,那么读取到的就是错误的数据。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

bSNrtvWTUKVCfi4ffa5668f14dcbdc9.png

每个线程都有一个自己的工作内存(相当于 CPU 高级缓冲区,这么做的目的还是在于进一步缩小存储系统与 CPU 之间速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。

如下例子:

//线程A执行的代码
int i = 0;
i = 10;

//线程B执行的代码
j = i;

假若执行线程A 的是 CPU1,执行线程B 的是 CPU2。

由内存模型的原理可知,当线程A 执行 i =10 这句时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为10,那么在 CPU1 的高速缓存当中 i 的值变为 10 了,却没有立即写入到主存当中。

此时线程B 执行 j = i,它会先去主存读取 i 的值并加载到 CPU2 的缓存当中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10。

这就是可见性问题,线程A 对变量 i 修改了之后,线程B 没有立即看到线程A 修改的值。

通过下面这段程序可以演示一下不可见的问题:

public class Temp {
static boolean ready;
static int number;

// 为了让代码更简洁,这里把睡眠方法提取出来,之所以要加这个方法是为了确保两个线程是 “同时” 执行的
static void sleep() {
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
// 读线程
new Thread(() -> {
sleep();
if (!ready) {
System.out.println("当前的 ready 为:" + ready);
}

System.out.println(number);
}).start();

// 写线程
new Thread(() -> {
sleep();
number = 100;
ready = true;
}).start();
}
}

从直观上理解,这段程序应该只会输出 100,ready 的值是不会打印出来的。实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,如下输出的三种结果

100
----------------------------------
当前的 ready 为:true
100
----------------------------------
当前的 ready 为:false
100

共享性

数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是在单线程编程的时候经常不需要考虑线程安全的主要原因之一。

但是,在多线程编程中,数据共享是不可避免的。最典型的场景是数据库中的数据,为了保证数据的一致性,通常需要共享同一个数据库中数据,即使是在主从的情况下,访问的也同一份数据,主从只是为了访问的效率和数据安全,而对同一份数据做的副本。现在,通过一个简单的示例来演示多线程下共享数据导致的问题:

public class Temp {

public static void main(String[] args) throws InterruptedException {
// 加个 CountDownLatch等待所有线程执行完成
CountDownLatch countDownLatch = new CountDownLatch(3);
Counter counter = new Counter();

for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 1000 * 10000; j++) {
counter.add(1);
}
countDownLatch.countDown();
}).start();
}

countDownLatch.await();
System.out.println(counter.count);
}

static class Counter {
private int count;
public void add(int n) {
count += n;
}
}
}

如上的代码,正常情况应该输出的是 3 * 1000 * 10000 但是实际输出才 11358682

可以看出,对共享变量操作,在多线程环境下很容易出现各种意想不到的的结果。

互斥性

资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。

通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。

所以通常将锁分为共享锁和排它锁,也叫做读锁和写锁。

如果资源不具有互斥性,即使是共享资源,也不需要担心线程安全。

例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。

但是对共享数据的写操作,一般就需要保证互斥性,上述例子中就是因为没有保证互斥性才导致数据的修改产生问题。

Java 中提供多种机制来保证互斥性,最简单的方式是使用 Synchronized。

public class Temp {

public static void main(String[] args) throws InterruptedException {
// 加个 CountDownLatch等待所有线程执行完成
CountDownLatch countDownLatch = new CountDownLatch(3);
Counter counter = new Counter();

for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 1000 * 10000; j++) {
counter.add(1);
}
countDownLatch.countDown();
}).start();
}

countDownLatch.await();
System.out.println(counter.count);
}

static class Counter {
private int count;
public synchronized void add(int n) {
count += n;
}
}
}